<style>
    .red-ui-editor {
        --nrdb-node-light: rgb(160, 230, 236);
        --nrdb-node-medium: rgb(90, 210, 220);
        --nrdb-node-dark: rgb(39, 183, 195);
        --nrdb-node-darkest: rgb(32 160 170);
    }
    .red-ui-editor .form-row-flex {
        display: flex;
        align-items: baseline;
        gap: 4px;
    }
    .red-ui-editor .form-row-flex input,
    .red-ui-editor .form-row-flex label:not(:first-child) {
        margin: 0;
        width: auto;
    }
    .nrdb2-helptext {
        font-size: 0.75rem;
        line-height: 0.825rem;
        color: var(--red-ui-tertiary-text-color);
        font-style: italic;
    }
    .w-16 {
        width: 16px;
    }
    #ff-node-red-dashboard {
        --ff-grey-50: #F9FAFB;
        --ff-grey-100: #F3F4F6;
        --ff-grey-200: #E5E7EB;
        position: absolute;
        top: 1px;
        bottom: 2px;
        left: 1px;
        right: 1px;
        overflow-y: auto;
    }
    #ff-node-red-dashboard .red-ui-sidebar-header {
        display: flex;
        justify-content: space-between;
    }
    #ff-node-red-dashboard .red-ui-sidebar-header label {
        margin-bottom: 0;
    }
    #ff-node-red-dashboard .red-ui-sidebar-header-actions {
        gap: 4px;
        display: flex;
    }
    #ff-node-red-dashboard .red-ui-editableList-container {
        padding: 0;
    }
    /* don't show border for nexted editable lists */
    .red-ui-editableList-border .red-ui-editableList-border {
        border: 0;
    }
    #node-config-client-constraints-nodes .red-ui-treeList-label.selected {
        background-color: #e3f2fd;
    }
    /* Dashboard 2.0 Sidebar */
    .nrdb2-sb-list-button-group {
        display: flex;
        gap: 4px;
    }
    .nrdb2-sidebar-tab-content {
        padding: 8px 10px;
        height: 100%;
        box-sizing: border-box;
        display: flex;
        flex-direction: column;
    }
    #dashboard-2-client-constraints a {
        color: blue;
    }
    #dashboard-2-client-constraints a:hover {
        text-decoration: underline;
    }
    .nrdb2-layout-helptext {
        padding: 0 0 9px;
        font-style: italic;
        color: #a2a2a2;
        font-size: 8pt;
        line-height: 12pt;
    }
    .nrdb2-sidebar-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 9px;
    }
    .nrdb2-sb-pages-list li {
        padding: 0;
        border-bottom: 0;
    }
    .nrdb2-sb-unattached-groups-list li {
        padding: 0;
        border-bottom: 0;
        background-color: #ffefef;
    }
    .nrdb2-sb-list-header {
        display: flex;
        gap: 6px;
        align-items: center;
        padding: 9px 6px;
        cursor: pointer;
    }
    .nrdb2-sb-list-header.nrdb2-sb-pages-list-header {
        border-top: 1px solid var(--red-ui-primary-border-color, --ff-grey-200);
        border-bottom: 1px solid var(--red-ui-primary-border-color, --ff-grey-200);
    }
    .nrdb2-sb-list-header .nrdb2-sb-title {
        text-overflow: ellipsis;
        overflow: hidden;
        white-space: nowrap;
    }
    .nrdb2-sb-list-header .nrdb2-sb-info {
        font-size: 0.75rem;
        color: var(--red-ui-tertiary-text-color);
    }
    .nrdb2-sb-list-header .nrdb2-sb-palette {
        display: flex;
        gap: 2px;
    }
    .nrdb2-sb-list-header .nrdb2-sb-palette-color {
        width: 12px;
        height: 12px;
        background-color: black;
        border: 1px solid #d4d4d4;
        border-radius: 2px;
    }
    .nrdb2-sb-list-header-actions {
        position: absolute;
        gap: 4px; 
        right: 1rem;
        display: flex;
        gap: 4px;
    }
    .nrdb2-sb-list-header-actions a {
        cursor: pointer;
    }
    .nrdb2-sb-list-header-state-options {
        display: flex;
        gap: 4px;
    }
    .nrdb2-sb-list-header-button-group {
        right: 1rem;
        display: flex;
        gap: 4px;
    }
    .nrdb2-sb-list-header-button-group,
    .nrdb2-sb-list-handle {
        opacity: 0;
        transition: 0.15s opacity;
    }
    .nrdb2-sb-list-header:hover {
        background-color: var(--red-ui-secondary-background-hover, --ff-grey-100);
    }
    .nrdb2-sb-list-header:hover .nrdb2-sb-list-handle, 
    .nrdb2-sb-list-header:hover .nrdb2-sb-list-header-button-group {
        opacity: 1;
    }
    /* indent the groups */
    .nrdb2-sb-groups-list-header .nrdb2-sb-list-chevron {
        margin-left: 1.5rem;
    }
    /* indent the widgets */
    .nrdb2-sb-widgets-list-header .nrdb2-sb-widget-icon {
        margin-left: 3.5rem;
    }
    /* apply subflow icon */
    .nrdb2-sb-widgets-list-header .nrdb2-sb-widget-icon.nrdb2-sb-subflow-icon {
        background-color: currentColor;
        display: inline-block;
        mask-image: url(red/images/subflow_tab.svg);
        mask-size: contain;
        mask-position: center;
        height: 18px;
        width: 18px;
        margin-left: 3.42rem;
    }
    #nrdb2-sb-client-data-providers {
        padding-left: 24px;
    }
    #nrdb2-sb-client-data-providers label {
        cursor: default;
    }
    #nrdb2-sb-client-data-providers-list {
        list-style: none;
        margin: 0;
        margin-bottom: 12px;
        border-radius: 4px;
        padding: 4px;
        border: 1px solid var(--red-ui-form-input-border-color);
    }
    #nrdb2-sb-client-data-providers-list li {
        display: flex;
        gap: 6px;
        align-items: center;
    }
    #nrdb2-sb-client-data-providers-list li:not(:first-child){
        border-top: 1px solid #e6e6e6;
    }
    #nrdb2-sb-client-data-providers-list li .fa {
        color: var(--red-ui-tertiary-text-color);
        font-size: 0.75rem;
    }
    #nrdb2-sb-client-data-providers-list li label {
        margin: 0;
        white-space: nowrap;
    }
    .nrdb2-sb-client-data-provider-package {
        color: var(--red-ui-tertiary-text-color);
        font-size: 0.75rem;
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
    }
    #node-config-client-constraints-nodes {
        flex-grow: 1;
        width: 100%;
    }
    #node-config-client-constraints-nodes-container {
        flex-grow: 1;
        display: flex;
        flex-direction: column;
    }
</style>


<script type="text/javascript">
// #region typedefs for intellisense and better code completion/DX

/**
 * @typedef {Object} DashboardItem - A widget/group/page/subflow item
 * @property {String} itemType - The type of item (e.g. 'widget', 'group', 'page', 'link')
 * @property {String} id - The unique id of the item
 * @property {String} name - The name of the item
 * @property {String} type - The type of the item (e.g. 'ui-button', 'ui-template', 'ui-group', 'ui-page')
 * @property {Number} order
 * @property {String} label
 * @property {String} color
 * @property {String} [group] - The group id that this widget belongs to
 * @property {String} [page] - The page id that this group belongs to
 * @property {String} [theme] - The theme id that this page belongs to
 * @property {Boolean} [isSubflowInstance] - Whether or not this item is a subflow instance
 * @property {String} [subflowName] - The name give to the subflow template (only applicable when `isSubflowInstance` is `true`)
 * @property {Object} node - The actual node or subflow instance that this DashboardItem represents
 * @global
 */

/** @typedef {Object<string, Array<DashboardItem>>} DashboardItemLookup */
// #endregion

(function () {
    const sidebarContainer = '<div style="position: relative; height: 100%;"></div>'
    const sidebarContentTemplate = $('<div id="ff-node-red-dashboard"></div>').appendTo(sidebarContainer)
    const sidebar = $(sidebarContentTemplate)

    // convert to i18 text
    function c_ (x) {
        return RED._('@flowfuse/node-red-dashboard/ui-base:ui-base.' + x)
    }

    function hasProperty (obj, prop) {
        return Object.prototype.hasOwnProperty.call(obj, prop)
    }
    function debounce (func, wait, immediate) {
        let timeout
        return function () {
            const context = this; const args = arguments
            const later = function () {
                timeout = null
                if (!immediate) func.apply(context, args)
            }
            const callNow = immediate && !timeout
            clearTimeout(timeout)
            timeout = setTimeout(later, wait)
            if (callNow) func.apply(context, args)
        }
    }

    RED.nodes.registerType('ui-base', {
        category: 'config',
        defaults: {
            name: {
                value: c_('label.uiName'),
                required: true
            },
            path: {
                value: '/dashboard',
                required: true
            },
            includeClientData: {
                value: true
            },
            acceptsClientConfig: {
                value: ['ui-notification', 'ui-control']
            },
            showPathInSidebar: {
                value: false
            },
            navigationStyle: {
                value: 'default'
            },
            titleBarStyle: {
                value: 'default'
            }
        },
        label: function () {
            return `${this.name} [${this.path}]` || 'UI Config'
        },
        oneditprepare: function () {
            // backward compatability for navigation style
            if (!this.titleBarStyle) {
                // set to default
                this.titleBarStyle = 'default'
                // update the jquery dropdown
                $('#node-config-input-titleBarStyle').val('default')
            }
        },
        onpaletteadd: function () {
            // add the Dashboard 2.0 sidebar
            if (RED._db2debug) { console.log('dashboard 2: ui_base.html: onpaletteadd ()') }
            addSidebar()
        }
    })

    /**
     * Utility function to convert a dashboard node to a DashboardItem
     * Provides a better DX with type checking and intellisense
     * @param {Object} node - The node to convert
     * @returns {DashboardItem}
     */
    function toDashboardItem (node) {
        if (RED._db2debug) { console.log('dashboard 2: ui_base.html: toDashboardItem (node)', node) }
        /** @type {DashboardItem} */
        const item = {
            itemType: 'widget',
            id: node.id,
            name: node.name,
            type: node.type,
            order: node.order,
            label: null,
            icon: null,
            color: null,
            isSubflowInstance: false,
            node
        }
        if (hasProperty(node, 'group')) { item.group = node.group }
        if (hasProperty(node, 'page')) { item.page = node.page }
        if (hasProperty(node, 'link')) { item.link = node.link }
        if (hasProperty(node, 'theme')) { item.theme = node.theme }
        if (hasProperty(node, 'env') && Array.isArray(node.env) && /subflow:.+/.test(node.type)) {
            const envOrder = node.env.find(e => e.key === 'DB2_SF_ORDER')
            if (envOrder) {
                item.order = envOrder.value
            }
        }
        switch (node.type) {
        case 'ui-page':
        case 'ui-link':
        case 'ui-group':
        case 'ui-theme':
        case 'ui-base':
            item.itemType = node.type.replace('ui-', '')
            break
        default:
            item.itemType = 'widget'
            break
        }
        try {
            item.order = parseInt(item.order)
        } finally {
            item.order = isNaN(item.order) ? 0 : item.order
        }
        try {
            item.label = RED.utils.getNodeLabel(node)
        } finally {
            item.label = item.label || node.type || node.id
        }
        try {
            item.color = RED.utils.getNodeColor(node)
        } catch (_err) { }
        return item
    }

    /**
     * Add Custom Dashboard Side Menu
     * */

    function dashboardLink (id, name, path) {
        const base = RED.settings.httpNodeRoot || '/'
        const basePart = base.endsWith('/') ? base : `${base}/`
        const dashPart = path.startsWith('/') ? path.slice(1) : path
        const fullPath = `${basePart}${dashPart}`

        const header = $('<div class="red-ui-sidebar-header"></div>')
        const label = $('<label></label>').text(name)

        const actions = $('<div class="red-ui-sidebar-header-actions"></div>')

        const editSettingsButton = $('<a id="edit-ui-base" class="editor-button editor-button-small nr-db-sb-list-header-button">' +
            c_('label.editSettings') + ' <i style="margin-left: 3px;" class="fa fa-cog"></i></a>')

        editSettingsButton.on('click', function () {
            RED.editor.editConfig('', 'ui-base', id)
        })

        const openDashboardButton = $(`<a id="open-dashboard" href="${fullPath}" target="nr-dashboard" class="editor-button editor-button-small nr-db-sb-list-header-button">` +
            c_('label.openDashboard') + ' <i style="margin-left: 3px;" class="fa fa-external-link"></i></a>')

        label.appendTo(header)
        editSettingsButton.appendTo(actions)
        openDashboardButton.appendTo(actions)
        actions.appendTo(header)
        return header
    }

    /**
     * Add an editor to control the ordering of groups & widgets
     */
    function updateItemOrder (items, events) {
        if (RED._db2debug) { console.log('dashboard 2: ui_base.html: updateItemOrder (items, events)', items, events) }
        items.each((i, el) => {
            /** @type {DashboardItem} */
            const dbItem = el.data('data')
            const node = dbItem?.node || {}
            const setNodeOrder = (newOrder) => {
                dbItem.order = newOrder
                if (dbItem.isSubflowInstance) {
                    node.env = node.env || []
                    const envOrder = node.env.find(e => e.key === 'DB2_SF_ORDER')
                    if (envOrder) {
                        envOrder.value = newOrder
                    } else {
                        node.env.push({ key: 'DB2_SF_ORDER', value: '' + newOrder, type: 'str' }) // db2 sort order
                    }
                } else {
                    node.order = newOrder
                }
            }
            const getNodeOrder = () => {
                let order = dbItem.order // node.order
                if (dbItem.isSubflowInstance && node.env) {
                    const envOrder = node.env.find(e => e.key === 'DB2_SF_ORDER')
                    if (envOrder) { order = envOrder.value }
                }
                if (typeof order === 'string') { order = parseInt(order) }
                if (isNaN(order)) { order = 0 }
                return order || 0
            }
            const oldOrder = getNodeOrder()
            if (oldOrder !== i + 1) {
                const wasDirty = node.dirty
                const wasChanged = node.changed
                // update Node-RED node properties
                setNodeOrder(i + 1)
                node.dirty = true
                node.changed = true
                // generate a history event
                const hev = {
                    t: 'edit',
                    node,
                    changes: {
                        order: oldOrder
                    },
                    dirty: wasDirty,
                    changed: wasChanged
                }
                events.push(hev)
            }
        })
    }

    // toggle slide tab group content
    const titleToggle = function (id, content, chevron) {
        return function (evt) {
            if (content.is(':visible')) {
                content.slideUp()
                chevron.css({ transform: 'rotate(-90deg)' })
                content.addClass('nr-db-sb-collapsed')
            } else {
                content.slideDown()
                chevron.css({ transform: '' })
                content.removeClass('nr-db-sb-collapsed')
            }
        }
    }

    // Utility function to store events in NR history, trigger a redraw, and detect if a re-deploy is necessary
    function recordEvents (events) {
        if (events.length === 0) { return } // nothing to record

        // note the state of the editor before pushing to history
        const isDirty = RED.nodes.dirty()
        if (RED._db2debug) { console.log('dashboard 2: recordEvents ()', isDirty, events) }

        // add our changes to NR history and trigger whether or not we need to redeploy
        RED.history.push({
            t: 'multi',
            events,
            dirty: isDirty
        })
        RED.nodes.dirty(true)
        RED.view.redraw()
    }

    function checkDuplicateUiBases () {
        // check how many ui-bases we have and trim if already too many
        const bases = []
        const pages = []
        RED.nodes.eachConfig((n) => {
            if (n.type === 'ui-base') { bases.push(n) }
            if (n.type === 'ui-page') { pages.push(n) }
        })

        // the eachConfig is in creation order, so we can remove from the end
        // and keep the oldest ones
        while (bases.length > 1) {
            const n = bases.pop()
            if (RED._db2debug) { console.log('ui-base removed', n) }
            RED.nodes.remove(n.id)
            RED.nodes.dirty(true)
        }

        if (bases.length > 0) {
            const baseId = bases[0].id
            // loop over pages and re-map the ui-base to the only ui-base available
            pages.forEach((page) => {
                page.ui = baseId
            })

            RED.nodes.eachNode((node) => {
                if (node.type.startsWith('ui-')) {
                    // check if the widgets are ui-scoped, and have a ui set to the removed ui-base nodes
                    if (node.ui && node.ui !== '' && node.ui !== baseId) {
                        node.ui = baseId
                    }
                }
            })
        }
    }

    function addConfigNode (node) {
        if (!node.users) {
            node.users = []
        }
        node.dirty = true
        RED.nodes.add(node)
        RED.editor.validateNode(node)
        RED.history.push({
            t: 'add',
            nodes: [node.id],
            dirty: RED.nodes.dirty()
        })
        RED.nodes.dirty(true)
    }

    function mapDefaults (defaults) {
        const values = {}
        for (const key in defaults) {
            if (Object.prototype.hasOwnProperty.call(defaults, key)) {
                values[key] = defaults[key].value
            }
        }
        return values
    }

    function getConfigNodesByType (type) {
        const nodes = []
        RED.nodes.eachConfig((n) => {
            if ((type instanceof String) && type === n.type) {
                nodes.push(n)
            } else {
                // we have an array of types
                if (type.includes(n.type)) {
                    nodes.push(n)
                }
            }
        })
        return nodes
    }

    function addDefaultPage (baseId, themeId) {
        const page = RED.nodes.getType('ui-page')
        // get all pages
        const entries = getConfigNodesByType(['ui-page', 'ui-link'])
        const pageNumber = entries.length + 1
        const pageNode = {
            _def: page,
            id: RED.nodes.id(),
            type: 'ui-page',
            ...mapDefaults(page.defaults),
            path: `/page${pageNumber}`, // TODO: generate a unique path
            name: `Page ${pageNumber}`,
            ui: baseId,
            theme: themeId,
            layout: 'grid',
            order: pageNumber
        }

        addConfigNode(pageNode)
        return pageNode
    }

    function addDefaultLink (baseId) {
        const link = RED.nodes.getType('ui-link')
        const linkNode = {
            _def: link,
            id: RED.nodes.id(),
            type: 'ui-link',
            ...mapDefaults(link.defaults),
            path: '/',
            name: 'Link',
            ui: baseId
        }

        addConfigNode(linkNode)
        return linkNode
    }

    function addDefaultGroup (pageId) {
        const group = RED.nodes.getType('ui-group')
        const groupNode = {
            _def: group,
            id: RED.nodes.id(),
            type: 'ui-group',
            ...mapDefaults(group.defaults),
            name: 'My Group',
            page: pageId
        }

        addConfigNode(groupNode)
        return groupNode
    }

    function addDefaultTheme () {
        const theme = RED.nodes.getType('ui-theme')
        const themeNode = {
            _def: theme,
            id: RED.nodes.id(),
            type: 'ui-theme',
            ...mapDefaults(theme.defaults),
            name: 'Default Theme'
        }

        addConfigNode(themeNode)
        return themeNode
    }

    function addLayoutsDefaults () {
        const cNodes = ['ui-base', 'ui-page', 'ui-group', 'ui-theme']
        let exists = false
        RED.nodes.eachConfig((n) => {
            if (cNodes.includes(n.type)) {
                exists = true
            }
        })

        // check if we haven't got any of these yet
        if (!exists) {
            // Add Single Base Node
            const base = RED.nodes.getType('ui-base')
            const baseNode = {
                _def: base,
                id: RED.nodes.id(),
                type: 'ui-base',
                ...mapDefaults(base.defaults),
                name: 'My Dashboard'
            }

            addConfigNode(baseNode)

            const theme = addDefaultTheme()
            const page = addDefaultPage(baseNode.id, theme.id)
            const group = addDefaultGroup(page.id)

            // update existing `ui-` nodes to use the new base/page/theme/group
            RED.nodes.eachNode((node) => {
                if (node.type.startsWith('ui-')) {
                    // if node has a group property
                    if (hasProperty(node._def.defaults, 'group') && !node.group) {
                        // group-scoped widgets - which is most of them
                        node.group = group.id
                    } else if (hasProperty(node._def.defaults, 'page') && !node.page) {
                        // page-scoped widgets
                        node.page = page.id
                    } else if (hasProperty(node._def.defaults, 'ui') && !node.ui) {
                        // base-scoped widgets, e.g. ui-notification/control
                        node.ui = baseNode.id
                    }
                    RED.editor.validateNode(node)
                }
            })

            RED.view.redraw()
            RED.sidebar.config.refresh()
        }
    }

    // watch for nodes changed, added, removed - use this to refresh the sidebar
    let refreshBusy = false // this is set/reset inside refreshSidebarEditors
    const refreshSidebarEditorDebounced = debounce(refreshSidebarEditors, 300)
    /**
     * Conditional refresh of the sidebar editor
     * The refresh is only triggered if the event is a ui- node or a subflow that contains a ui- node
     * Calls are debounced to prevent multiple calls to refreshSidebarEditors
     * @param {Object} node - The node that was changed/added/removed
     * @param {String} eventName - The name of the event that was fired
     */
    const conditionalSidebarRefresh = function (node, eventName) {
        // if the layout editor is not in view, don't refresh
        if ($('#ff-node-red-dashboard').parent().css('display') === 'none') { return }
        // if a refresh is in progress, don't refresh
        if (refreshBusy) { return }
        if (RED._db2debug) { console.log('dashboard 2: conditionalSidebarRefresh (node, eventName)', node, eventName) }

        // first, check if the node.dirty flag is set or if the event is a nodes:remove (also ensure the node has a type property)
        if ((node.dirty || eventName === 'nodes:remove') && node.type) {
            // check if the node is a ui- node
            let refresh = node.type.startsWith('ui-')
            // if this is not a ui- node, it is perhaps a subflow?
            if (!refresh && node.type.startsWith('subflow:')) {
                // lets see if it has env vars linked to a `ui-` config or it has a DB2_SF_ORDER sort order key?
                const subflowId = node.type.split(':')[1]
                if (node.env && node.env.find(e => e.type?.startsWith('ui-') || node.env.find(e => e.key === 'DB2_SF_ORDER'))) {
                    refresh = true
                }
                if (!refresh) {
                    // check if the subflow definition contains any ui- nodes
                    const subflowChildren = RED.nodes.filterNodes({ z: subflowId })
                    if (subflowChildren.some(n => n.type.startsWith('ui-'))) {
                        refresh = true
                    }
                }
            }
            if (refresh) {
                if (RED._db2debug) { console.log(`dashboard 2: ${eventName} - this is a ui- node! queuing a call to refreshSidebarEditors`) }
                // debounce the call to refreshSidebarEditors as multiple events can be fired in quick succession
                refreshSidebarEditorDebounced()
            }
        }
    }

    RED.events.on('nodes:change', function (event) {
        conditionalSidebarRefresh(event, 'nodes:change')
    })

    const addLayoutsDefaultsDebounced = debounce(addLayoutsDefaults, 25)
    const checkDuplicateUiBasesDebounced = debounce(checkDuplicateUiBases, 25)
    RED.events.on('nodes:add', function (node) {
        if (RED._db2debug) { console.log('nodes:add', node) }
        if (node.dirty && node.type && node.type.startsWith('ui-')) {
            if (RED._db2debug) { console.log('nodes:add - this is a ui- node! queuing a call to refreshSidebarEditors') }
            // debounce the call to refreshSidebarEditors as multiple events can be fired in quick succession
            refreshSidebarEditorDebounced()
        }

        // if we're adding a ui-base
        if (node.type.startsWith('ui-')) {
            // action on all ui- elements to ensure we've remapped (now) missing ui-base nodes
            checkDuplicateUiBasesDebounced()
            addLayoutsDefaultsDebounced()
        }
    })

    RED.events.on('nodes:remove', function (event) {
        conditionalSidebarRefresh(event, 'nodes:remove')
    })

    /**
     * Add group of actions to the right-side of a row in the sidebar editable list.
     * @param {Object} parent - jQuery object to add this button group as a child element to
     * @param {DashboardItem} item - The page/group/widget that these actions are bound to
     */
    function addRowActions (parent, item, list) {
        const configNodes = ['ui-base', 'ui-page', 'ui-link', 'ui-group', 'ui-theme']
        const btnGroup = $('<div>', { class: 'nrdb2-sb-list-header-button-group', id: item.id }).appendTo(parent)
        if (!configNodes.includes(item.type)) {
            const focusButton = $('<a href="#" class="nr-db-sb-tab-focus-button editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-bullseye"></i> ' + c_('layout.focus') + '</a>').appendTo(btnGroup)
            focusButton.on('click', function (evt) {
                RED.view.reveal(item.id)
                evt.stopPropagation()
                evt.preventDefault()
            })
        }
        const editButton = $('<a href="#" class="nr-db-sb-tab-edit-button editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-pencil"></i> ' + c_('layout.edit') + '</a>').appendTo(btnGroup)
        editButton.on('click', function (evt) {
            if (configNodes.includes(item.type)) {
                RED.editor.editConfig('', item.type, item.id)
            } else {
                RED.editor.edit(item?.node)
            }
            evt.stopPropagation()
            evt.preventDefault()
        })
        if (item.type === 'ui-page') {
            // add the "+ group" button
            $('<a href="#" class="editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-plus"></i> ' + c_('layout.group') + '</a>')
                .click(function (evt) {
                    list.editableList('addItem')
                    evt.preventDefault()
                })
                .appendTo(btnGroup)
        }
    }

    /**
     * Add group of actions to the right-side of a row in the sidebar editable list.
     * @param {Object} parent - jQuery object to add this button group as a child element to
     * @param {DashboardItem} dashboardItem - The page/group/widget that these actions are bound to
     */
    function addRowStateOptions (parent, dashboardItem) {
        const item = dashboardItem.node
        const nodes = ['ui-page', 'ui-link', 'ui-group']
        const btnGroup = $('<div>', { class: 'nrdb2-sb-list-header-state-options', id: item.id }).appendTo(parent)
        if (nodes.includes(item.type)) {
            const visibleIcon = (item.visible === 'false' || item.visible === false) ? 'fa-eye-slash' : 'fa-eye'
            const visibleBtn = $('<a href="#" title="Hide" class="nr-db-sb-tab-visible-button editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa ' + visibleIcon + '"></i></a>').appendTo(btnGroup)
            visibleBtn.on('click', function (evt) {
                const events = []

                evt.stopPropagation()
                evt.preventDefault()

                if (item.visible === 'true' || item.visible === true || item.visible === undefined) {
                    // toggle to hidden
                    item.visible = false
                    $(this).prop('title', 'Show')
                    $(this).find('i').addClass('fa-eye-slash').removeClass('fa-eye')
                    events.push({
                        t: 'edit',
                        node: item,
                        changes: {
                            visible: true
                        },
                        dirty: item.dirty,
                        changed: item.changed
                    })
                } else {
                    // toggle to shown
                    item.visible = true
                    $(this).prop('title', 'Hide')
                    $(this).find('i').addClass('fa-eye').removeClass('fa-eye-slash')
                    events.push({
                        t: 'edit',
                        node: item,
                        changes: {
                            visible: false
                        },
                        dirty: item.dirty,
                        changed: item.changed
                    })
                }
                recordEvents(events)
            })
            const disabledIcon = (item.disabled === 'true' || item.disabled === true) ? 'fa-lock' : 'fa-unlock'
            const disabledBtn = $('<a href="#" class="nr-db-sb-tab-visible-button editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa ' + disabledIcon + '"></i></a>').appendTo(btnGroup)
            disabledBtn.on('click', function (evt) {
                const events = []

                evt.stopPropagation()
                evt.preventDefault()

                if (item.disabled === 'true' || item.disabled === true) {
                    // toggle to hidden
                    item.disabled = false
                    $(this).prop('title', 'Disable')
                    $(this).find('i').addClass('fa-unlock').removeClass('fa-lock')
                    events.push({
                        t: 'edit',
                        node: item,
                        changes: {
                            disabled: true
                        },
                        dirty: item.dirty,
                        changed: item.changed
                    })
                } else {
                    // toggle to shown
                    item.disabled = true
                    $(this).prop('title', 'Enable')
                    $(this).find('i').addClass('fa-lock').removeClass('fa-unlock')
                    events.push({
                        t: 'edit',
                        node: item,
                        changes: {
                            disabled: false
                        },
                        dirty: item.dirty,
                        changed: item.changed
                    })
                }
                recordEvents(events)
            })
        }
    }

    /**
     * Adds child list of groups for a given page
     * @param {String} pageId - The id of the page that these groups belong to
     * @param {Object} container - The jQuery object to append the groups list to
     * @param {Object[]} groups - The list of groups to add to the list
     * @param {DashboardItemLookup} widgetsByGroup - The lookup of widgets by group
     */
    function addGroupOrderingList (pageId, container, groups, widgetsByGroup) {
        // ordered list of groups to live within a container (e.g. page list item)
        const groupsOL = $('<ol>', { class: 'nrdb2-sb-group-list' }).appendTo(container).editableList({
            sortable: '.nrdb2-sb-groups-list-header',
            addButton: false,
            height: 'auto',
            connectWith: '.nrdb2-sb-group-list',
            addItem: function (container, i, group) {
                if (!group || !group.id) {
                    // this is a new page that's been added and we need to setup the basics
                    group = addDefaultGroup(pageId)
                    RED.editor.editConfig('', group.type, group.id)
                }

                const widgets = widgetsByGroup[group.id] || []
                const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-groups-list-header' }).appendTo(container)
                $('<i class="nrdb2-sb-list-handle nrdb2-sb-group-list-handle fa fa-bars"></i>').appendTo(titleRow)
                const chevron = $('<i class="fa fa-angle-down nrdb2-sb-list-chevron">', { style: 'width:10px;' }).appendTo(titleRow)
                const groupicon = 'fa-table'
                $('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-group-icon fa ' + groupicon }).appendTo(titleRow)
                $('<span>', { class: 'nrdb2-sb-title' }).text(group.name || group.id).appendTo(titleRow)
                $('<span>', { class: 'nrdb2-sb-info' }).text(`${widgets.length} Widgets`).appendTo(titleRow)

                const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
                addRowActions(actions, group)
                addRowStateOptions(actions, group)

                // adds widgets within this group
                const widgetsList = $('<div>', { class: 'nrdb2-sb-widget-list-container' }).appendTo(container)

                // add chevron/list toggle
                titleRow.click(titleToggle(group.id, widgetsList, chevron))

                addWidgetToList(group.id, widgetsList, widgets)
            },
            sortItems: function (items) {
                // track any changes
                const events = []

                // check if we have any new widgets added to this list
                items.each((i, el) => {
                    const dbItem = el.data('data')
                    const widget = dbItem?.node || {}
                    if (widget.page !== pageId) {
                        const oldPageId = widget.page
                        widget.page = pageId
                        events.push({
                            t: 'edit',
                            node: widget,
                            changes: {
                                page: oldPageId
                            },
                            dirty: widget.changed,
                            changed: widget.dirty
                        })
                    }
                })

                updateItemOrder(items, events)

                // add our changes to NR history and trigger whether or not we need to redeploy
                recordEvents(events)
            },
            sort: function (a, b) {
                return Number(a.order) - Number(b.order)
            }
        })

        groups.forEach(function (group) {
            if (RED._db2debug) { if (RED._db2debug) { console.log('dashboard 2: ui_base.html: addGroupOrderingList: adding group', group) } }
            groupsOL.editableList('addItem', group)
        })

        return groupsOL
    }

    /**
     *  Adds  list of widgets underneath a group
     * @param {String} groupId - The id of the group that these widgets belong to
     * @param {JQuery} container - The jQuery object to append the widgets list to
     * @param {DashboardItem[]} widgets - The list of widgets to add to the list
     */
    function addWidgetToList (groupId, container, widgets) {
        // ordered list of groups to live within a container (e.g. page list item)
        const widgetsOL = $('<ol>', { class: 'nrdb2-sb-widget-list' }).appendTo(container).editableList({
            sortable: '.nrdb2-sb-widgets-list-header',
            addButton: false,
            height: 'auto',
            connectWith: '.nrdb2-sb-widget-list',
            addItem: function (container, i, /** @type {DashboardItem} */ widget) {
                const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-widgets-list-header' }).appendTo(container)
                $('<i class="nrdb2-sb-list-handle nrdb2-sb-widget-list-handle fa fa-bars"></i>').appendTo(titleRow)
                let widgetIcon = 'fa fa-image'
                if (widget.isSubflowInstance) {
                    // In this MVP, subflow instances are constrained to stay within own group.
                    container.parent().addClass('red-ui-editableList-item-constrained')
                    // change the icon to use the built-in subflow icon
                    widgetIcon = 'nrdb2-sb-subflow-icon'
                    // apply a tooltip to further clarify this is a subflow
                    titleRow.attr('title', widget.subflowName || 'Subflow instance')
                }
                $('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-widget-icon ' + widgetIcon }).appendTo(titleRow)
                $('<span>', { class: 'nrdb2-sb-title' }).text(widget.label?.trim() || widget.id).appendTo(titleRow)
                const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
                addRowActions(actions, widget)
            },
            sortItems: function (items) {
                // track any changes
                const events = []

                // check if we have any new widgets added to this list
                items.each((i, el) => {
                    const dbItem = el.data('data')
                    const widget = dbItem?.node || {}
                    if (widget.group !== groupId) {
                        const oldGroupId = widget.group
                        widget.group = groupId
                        events.push({
                            t: 'edit',
                            node: widget,
                            changes: {
                                group: oldGroupId
                            },
                            dirty: widget.dirty,
                            changed: widget.changed
                        })
                    }
                })

                updateItemOrder(items, events)

                // add our changes to NR history and trigger whether or not we need to redeploy
                recordEvents(events)
            },
            sort: function (a, b) {
                return Number(a.order) - Number(b.order)
            }
        })

        widgets.forEach(function (w) {
            widgetsOL.editableList('addItem', w)
        })
    }

    // expand / collapse buttons
    let layoutDisplayLevel = 2 // all open by default
    const getGroupsInLayout = function () {
        const content = $('.nrdb2-layout-order-editor > .red-ui-editableList .nrdb2-sb-widget-list-container')
        return {
            content,
            chevrons: content.parent().find('div.nrdb2-sb-list-header > .nrdb2-sb-list-chevron')
        }
    }
    const getPagesInLayout = function () {
        const content = $('.nrdb2-layout-order-editor > .red-ui-editableList .nrdb2-sb-group-list-container')
        return {
            content,
            chevrons: content.parent().find('div.nrdb2-sb-pages-list-header > .nrdb2-sb-list-chevron')
        }
    }
    const collapseLayoutItems = function ({ chevrons, content }) {
        chevrons.css({ transform: 'rotate(-90deg)' })
        content.slideUp()
        content.addClass('nr-db-sb-collapsed')
    }
    const expandLayoutItems = function ({ chevrons, content }) {
        chevrons.css({ transform: '' })
        content.slideDown()
        content.removeClass('nr-db-sb-collapsed')
    }
    /**
     * Update the visibility of the layout editor expandable lists
     * @param {0|1|2} level - 0 = collapse all, 1 = expand pages (groups collapsed), 2 = expand pages and groups (to expose widgets)
     */
    const updateLayoutVisibility = function (level) {
        if (RED._db2debug) { console.log('dashboard 2: ui_base.html: updateLayoutVisibility(level)', level) }
        if (level === 2) {
            expandLayoutItems(getGroupsInLayout())
            expandLayoutItems(getPagesInLayout())
        } else if (level === 1) {
            expandLayoutItems(getPagesInLayout())
            collapseLayoutItems(getGroupsInLayout())
        } else {
            collapseLayoutItems(getGroupsInLayout())
            collapseLayoutItems(getPagesInLayout())
        }
    }

    /**
     * Sidebar Functions
     */

    function buildLayoutOrderEditor (parent) {
        if (RED._db2debug) { console.log('dashboard 2: ui_base.html: buildLayoutOrderEditor (parent)', parent) }

        // layout/order editor
        const divTabs = $('.nrdb2-layout-order-editor').length ? $('.nrdb2-layout-order-editor') : $('<div>', { class: 'nrdb2-layout-order-editor nrdb2-sidebar-tab-content' }).appendTo(parent)

        // section header - Pages
        const pagesHeader = $('<div>', { class: 'nrdb2-sidebar-header' }).appendTo(divTabs)
        $('<b>').html(c_('layout.pages')).appendTo(pagesHeader)

        // toggle "all" buttons
        const buttonGroup = $('<div>', { class: 'nrdb2-sb-list-button-group' }).appendTo(pagesHeader)

        const buttonCollapse = $('<a href="#" class="editor-button editor-button-small nrdb2-sb-list-header-button"><i class="fa fa-angle-double-up"></i></a>')
            .click(function (evt) {
                evt.preventDefault()
                if (--layoutDisplayLevel < 0) { layoutDisplayLevel = 0 }
                updateLayoutVisibility(layoutDisplayLevel)
            })
            .appendTo(buttonGroup)
        RED.popover.tooltip(buttonCollapse, c_('layout.collapse'))

        // expand button
        const buttonExpand = $('<a href="#" class="editor-button editor-button-small nrdb2-sb-list-header-button"><i class="fa fa-angle-double-down"></i></a>')
            .click(function (evt) {
                if (++layoutDisplayLevel > 2) { layoutDisplayLevel = 2 }
                updateLayoutVisibility(layoutDisplayLevel)
            }).appendTo(buttonGroup)
            .appendTo(buttonGroup)
        RED.popover.tooltip(buttonExpand, c_('layout.expand'))

        // add link button
        $('<a href="#" class="editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-plus"></i> ' + c_('layout.link') + '</a>')
            .click(function (evt) {
                pagesOL.editableList('addItem', { type: 'ui-link' })
                evt.preventDefault()
            })
            .appendTo(buttonGroup)

        // add page button
        $('<a href="#" class="editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-plus"></i> ' + c_('layout.page') + '</a>')
            .click(function (evt) {
                pagesOL.editableList('addItem')
                evt.preventDefault()
            })
            .appendTo(buttonGroup)

        divTabs.append('<div class="nrdb2-layout-helptext">' + c_('label.layoutMessage') + '</div>')

        /** @type {DashboardItemLookup} */
        const pages = {}
        const links = {}
        /** @type {DashboardItemLookup} */
        const groupsByPage = {}
        const unattachedGroups = []
        /** @type {DashboardItemLookup} */
        const widgetsByGroup = {}
        const subflowDefinitions = new Map()
        // get all pages & all groups
        RED.nodes.eachConfig(function (n) {
            if (n.type === 'ui-page' && !!n.ui) {
                pages[n.id] = toDashboardItem(n)
            } else if (n.type === 'ui-link') {
                links[n.id] = toDashboardItem(n)
            } else if (n.type === 'ui-group') {
                const p = n.page
                if (!p) {
                    unattachedGroups.push(toDashboardItem(n))
                } else {
                    if (!groupsByPage[p]) {
                        groupsByPage[p] = []
                    }
                    groupsByPage[p].push(toDashboardItem(n))
                }
            }
        })

        // get all widgets
        const uiNodesWithGroupProp = []
        RED.nodes.eachNode(function (n) {
            if (/^ui-/.test(n.type) && n.group) {
                uiNodesWithGroupProp.push(n)
                if (!widgetsByGroup[n.group]) {
                    widgetsByGroup[n.group] = []
                }
                widgetsByGroup[n.group].push(toDashboardItem(n))
                if (n.z) {
                    const subflowDef = RED.nodes.subflow(n.z)
                    if (subflowDef) {
                        subflowDefinitions.set('subflow:' + subflowDef.id, subflowDef)
                    }
                }
            }
        })

        // update `widgetsByGroup` for subflow instances where its env has an entry which
        // is a ui-group
        RED.nodes.eachNode(function (n) {
            if (subflowDefinitions.has(n.type)) {
                const subflowDef = subflowDefinitions.get(n.type)
                const subflowInstance = n
                if (subflowDef && subflowDef.env && subflowDef.env.length > 0) {
                    /** @type {{name:String, type:String, value:String}[]} */
                    const envDefsWithUIGroup = subflowDef.env.filter(env => env.type === 'ui-group' && env.ui?.type === 'conf-types')
                    for (const envDef of envDefsWithUIGroup) {
                        const groupNameAsEnv = '${' + envDef.name + '}'

                        if (widgetsByGroup[groupNameAsEnv]) {
                            // at this point, we know that the widgets in widgetsByGroup[groupNameAsEnv] belong to the group defined by the env var of the subflow instance
                            // get the actual group id from the subflow instance env vars where the name matches the envDef.name
                            if (subflowInstance.env) {
                                const groupEnvVar = subflowInstance.env.find(env => env.name === envDef.name)
                                if (groupEnvVar) {
                                    const groupId = groupEnvVar.value
                                    if (!widgetsByGroup[groupId]) {
                                        widgetsByGroup[groupId] = []
                                    }
                                    const sfItem = toDashboardItem(n)
                                    sfItem.isSubflowInstance = true
                                    sfItem.subflowName = RED.utils.getNodeLabel(subflowDef)
                                    if (!sfItem.label?.trim() || sfItem.label === sfItem.id) {
                                        // label was defaulted to id, so we should use the label from the subflowDef
                                        sfItem.label = sfItem.subflowName
                                    }
                                    widgetsByGroup[groupId].push(sfItem)
                                }
                            }
                        }
                    }
                }
            }
        })

        const pagesOL = $('<ol>', { class: 'nrdb2-sb-pages-list' }).appendTo(divTabs).editableList({
            sortable: '.nrdb2-sb-pages-list-header',
            addButton: false,
            addItem: function (container, i, item) {
                if (item && item.type === 'ui-link') {
                    // want to create a new link
                    if (!item || !item.id) {
                        // create a default link
                        item = addDefaultLink()
                        RED.editor.editConfig('', item.type, item.id)
                    }
                    // add it to the list of pages/links
                    container.addClass('nrdb2-sb-pages-list-item')
                    const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-pages-list-header' }).appendTo(container)

                    // build title row
                    $('<i class="nrdb2-sb-list-handle nrdb2-sb-page-list-handle fa fa-bars"></i>').appendTo(titleRow)
                    const linkIcon = 'fa-link'
                    $('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-tab-icon fa ' + linkIcon }).appendTo(titleRow)
                    $('<span>', { class: 'nrdb2-sb-title' }).text(item.name || item.id).appendTo(titleRow)

                    // link - actions
                    const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
                    // add "Edit" and "Focus" buttons
                    addRowActions(actions, item)
                    // Add visibility/disabled options
                    addRowStateOptions(actions, item)
                } else {
                    // is a page, with groups and widgets inside
                    if (!item || !item.id) {
                        // this is a new page that's been added and we need to setup the basics
                        item = addDefaultPage()
                        RED.editor.editConfig('', item.type, item.id)
                    }
                    const groups = groupsByPage[item.id] || []

                    container.addClass('nrdb2-sb-pages-list-item')

                    const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-pages-list-header' }).appendTo(container)
                    const groupsList = $('<div>', { class: 'nrdb2-sb-group-list-container' }).appendTo(container)

                    // build title row
                    $('<i class="nrdb2-sb-list-handle nrdb2-sb-page-list-handle fa fa-bars"></i>').appendTo(titleRow)
                    const chevron = $('<i class="fa fa-angle-down nrdb2-sb-list-chevron">', { style: 'width:10px;' }).appendTo(titleRow)
                    const tabicon = 'fa-object-group'
                    $('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-tab-icon fa ' + tabicon }).appendTo(titleRow)
                    $('<span>', { class: 'nrdb2-sb-title' }).text(item.name || item.id).appendTo(titleRow)
                    $('<span>', { class: 'nrdb2-sb-info' }).text(`${groups.length} Groups`).appendTo(titleRow)

                    // adds groups within this page
                    titleRow.click(titleToggle(item.id, groupsList, chevron))
                    const groupsOL = addGroupOrderingList(item.id, groupsList, groups, widgetsByGroup)

                    // page - actions
                    const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
                    addRowActions(actions, item, groupsOL)
                    addRowStateOptions(actions, item)
                }
                // ensure all pages/links have the correct order value
                const events = []
                updateItemOrder(pagesOL.editableList('items'), events)
                // add our changes to NR history and trigger whether or not we need to redeploy
                recordEvents(events)
            },
            sortItems: function (items) {
                // runs when an item is explicitly moved in the order
                // track any changes
                const events = []
                updateItemOrder(items, events)

                // add our changes to NR history and trigger whether or not we need to redeploy
                recordEvents(events)
            },
            sort: function (a, b) {
                return Number(a.order) - Number(b.order)
            }
        })

        const items = {
            ...pages,
            ...links
        }

        Object.values(items).sort((a, b) => a.order - b.order).forEach(function (item) {
            let groups = []
            if (item.type === 'ui-page' && item.id) {
                if (RED._db2debug) { console.log('dashboard 2: ui_base.html: buildLayoutOrderEditor: adding groups', groups) }
                groups = groupsByPage[item.id] || []
            }
            if (item) {
                pagesOL.editableList('addItem', item)
            }
        })

        // add Unattached Groups to the bottom
        if (unattachedGroups.length > 0) {
            const unattachedGroupsSection = $('<div>', { class: 'nrdb2-sidebar-header', style: 'padding-top: 12px;' }).appendTo(divTabs)
            $('<b>').html(c_('layout.unattachedGroups')).appendTo(unattachedGroupsSection)
            divTabs.append('<div class="nrdb2-layout-helptext">' + c_('label.unattachedMessage') + '</div>')

            // we have some groups bound to a page that no longer exists
            const unattachedGroupsOL = $('<ol>', { class: 'nrdb2-sb-unattached-groups-list' }).appendTo(divTabs).editableList({
                sortable: '.nrdb2-sb-unattached-groups-list-header',
                addButton: false,
                addItem: function (container, i, group) {
                    if (!group || !group.id) {
                        // this is a new group that's been added and we need to setup the basics
                        group = addDefaultGroup()
                        RED.editor.editConfig('', group.type, group.id)
                    }
                    container.addClass('nrdb2-sb-unattached-groups-list-item')

                    const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-unattached-groups-list-header' }).appendTo(container)
                    const tabicon = 'fa-table'
                    $('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-tab-icon fa ' + tabicon }).appendTo(titleRow)
                    $('<span>', { class: 'nrdb2-sb-title' }).text(group.name || group.id).appendTo(titleRow)
                    $('<span>', { class: 'nrdb2-sb-info' }).text('No Page Assigned').appendTo(titleRow)

                    // group - actions
                    const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
                    addRowActions(actions, group)
                }
            })

            unattachedGroups.forEach(function (group) {
                unattachedGroupsOL.editableList('addItem', group)
            })
        }

        // call updateLayoutVisibility to sync display level
        updateLayoutVisibility(layoutDisplayLevel)
    }

    function buildThemesEditor (parent) {
        // layout/order editor
        const divTabs = $('.nrdb2-themes-editor').length ? $('.nrdb2-themes-editor') : $('<div>', { class: 'nrdb2-themes-editor nrdb2-sidebar-tab-content' }).appendTo(parent)

        // section header - Pages
        const themeHeader = $('<div>', { class: 'nrdb2-sidebar-header' }).appendTo(divTabs)
        $('<b>').html(c_('themes.header')).appendTo(themeHeader)

        // button group
        const buttonGroup = $('<div>', { class: 'nrdb2-sb-list-button-group' }).appendTo(themeHeader)

        // add theme button
        $('<a href="#" class="editor-button editor-button-small nr-db-sb-list-header-button"><i class="fa fa-plus"></i> ' + c_('themes.theme') + '</a>')
            .click(function (evt) {
                themesOL.editableList('addItem')
                evt.preventDefault()
            })
            .appendTo(buttonGroup)

        divTabs.append('<div class="nrdb2-layout-helptext">' + c_('label.themingMessage') + '</div>')

        const themes = {}

        // get all themes
        RED.nodes.eachConfig(function (n) {
            if (n.type === 'ui-theme') {
                themes[n.id] = n
            }
        })

        const themesOL = $('<ol>', { class: 'nrdb2-sb-pages-list' }).appendTo(divTabs).editableList({
            sortable: '.nrdb2-sb-pages-list-header',
            addButton: false,
            addItem: function (container, i, theme) {
                if (!theme || !theme.id) {
                    // this is a new theme that's been added and we need to setup the basics
                    theme = addDefaultTheme()
                    RED.editor.editConfig('', theme.type, theme.id)
                }
                container.addClass('nrdb2-sb-pages-list-item')

                const titleRow = $('<div>', { class: 'nrdb2-sb-list-header nrdb2-sb-themes-list-header' }).appendTo(container)
                const tabicon = 'fa-paint-brush'
                $('<i>', { class: 'nrdb2-sb-icon nrdb2-sb-tab-icon fa ' + tabicon }).appendTo(titleRow)
                $('<span>', { class: 'nrdb2-sb-title' }).text(theme.name || theme.id).appendTo(titleRow)
                $('<span>', { class: 'nrdb2-sb-info' }).text(theme.users.length + ' ' + (theme.users.length > 1 ? c_('label.pages') : c_('label.page'))).appendTo(titleRow)

                const palette = $('<div>', { class: 'nrdb2-sb-palette' }).appendTo(titleRow)
                const colors = theme.colors

                palette.append($('<div>', { class: 'nrdb2-sb-palette-color', style: `background-color: ${colors.surface}` }))
                palette.append($('<div>', { class: 'nrdb2-sb-palette-color', style: `background-color: ${colors.primary}` }))
                palette.append($('<div>', { class: 'nrdb2-sb-palette-color', style: `background-color: ${colors.bgPage}` }))
                palette.append($('<div>', { class: 'nrdb2-sb-palette-color', style: `background-color: ${colors.groupBg}` }))
                palette.append($('<div>', { class: 'nrdb2-sb-palette-color', style: `background-color: ${colors.groupOutline}` }))

                // theme - actions
                const actions = $('<div>', { class: 'nrdb2-sb-list-header-actions' }).appendTo(titleRow)
                addRowActions(actions, theme)
            }
        })

        Object.values(themes).forEach(function (theme) {
            themesOL.editableList('addItem', theme)
        })
    }

    /**
     * Builds the client constraints editor
     * @param {Object} base - The ui-base node we are representing
     * @param {Object} parent - The parent element to append the client constraints editor to
     */
    function buildClientConstraintsEditor (base, parent) {
        const html = `<div class="nrdb2-sidebar-tab-content">
        <div style="margin-bottom: 9px;border-bottom: 1px solid #ddd;">
            <p>
                This tab allows you to control whether or not client-specific data is included in messages,
                and which nodes accept it in order to constrain communication to specific clients.
                You can read more about it <a href="https://dashboard.flowfuse.com/user/sidebar.html#client-data" target="_blank">here</a>
            </p>
        </div>
        <div class="form-row form-row-flex">
            <input type="checkbox" id="node-config-input-includeClientData">
            <label style="width:auto" for="node-config-input-includeClientData"><i class="fa fa-id-card-o" style="margin-right:4px;"></i>Include Client Data</label>
        </div>
        <p class="nrdb2-helptext" style="margin-top: -12px;">
            <i class="fa fa-info-circle"></i> This option includes client data in all messages transmitted
            by Dashboard 2.0 (e.g. Socket ID and information about any logged in user)
        </p>
        <div id="nrdb2-sb-client-data-providers">
            <label>Client Data Providers:</label>
            <ul id="nrdb2-sb-client-data-providers-list"></ul>
        </div>
        <div id="node-config-client-constraints-nodes-container" class="form-row">
            <label style="display: block; width: auto;"><i class="fa fa-id-card-o" style="margin-right:4px;"></i>Accept Client Data</label>
            <p class="nrdb2-helptext" style="margin-top: -4px;">
                <i class="fa fa-info-circle"></i> Defines whether the respective node type will use client data
                (e.g. socketid), and constrain communications to just that client.
            </p>
            <div id="node-config-client-constraints-nodes"></div>
        </div></div>`

        $(html).appendTo(parent)

        if (base.includeClientData === undefined) {
            // older ui-base nodes may not have this property
            base.includeClientData = true
            const events = [{
                t: 'edit',
                node: base,
                changes: {
                    includeClientData: undefined
                },
                dirty: true,
                changed: true
            }]
            recordEvents(events)
        }

        // set initial state
        $('#node-config-input-includeClientData')
            .prop('checked', base.includeClientData)

        // add event handler to our checkbox for including client data
        $('#node-config-input-includeClientData')
            .on('change', function (event) {
                const value = event.target.checked
                base.includeClientData = value
                // store hte previous value in history
                const events = [{
                    t: 'edit',
                    node: base,
                    changes: {
                        includeClientData: !value
                    },
                    dirty: true,
                    changed: true
                }]
                recordEvents(events)
            })

        // add core as a data provider as it provides socketId
        const coreLi = $('<li>').appendTo('#nrdb2-sb-client-data-providers-list')
        $('<i class="fa fa-bar-chart"></i>').appendTo(coreLi)
        $('<label></label>').text('Socket ID').appendTo(coreLi)
        $('<span class="nrdb2-sb-client-data-provider-package"></span>').text('@flowfuse/node-red-dashboard').appendTo(coreLi)

        // detail any auth providers we have
        // check for any third-party tab definitions
        RED.plugins.getPluginsByType('node-red-dashboard-2').forEach(plugin => {
            if (plugin.auth) {
                const li = $('<li>').appendTo('#nrdb2-sb-client-data-providers-list')
                $('<i class="fa fa-plug"></i>').appendTo(li)
                $('<label></label>').text(plugin.description).appendTo(li)
                $('<span class="nrdb2-sb-client-data-provider-package"></span>').text(plugin.module).appendTo(li)
                $('<li>').text(plugin.description).appendTo('#node-config-client-data-providers-list')
            }
        })

        // build list of node types
        const widgets = RED.nodes.registry.getNodeTypes().filter((nodeType) => {
            const type = RED.nodes.getType(nodeType)
            return /^ui-/.test(nodeType) && type.category !== 'config' && type.inputs > 0
        })

        if (base.acceptsClientConfig === undefined) {
            // older ui-base nodes may not have this property
            base.acceptsClientConfig = ['ui-control', 'ui-notification']
            const events = [{
                t: 'edit',
                node: base,
                changes: {
                    acceptsClientConfig: undefined
                },
                dirty: true,
                changed: true
            }]
            recordEvents(events)
        }

        $('#node-config-client-constraints-nodes')
            .treeList({
                data: widgets.map((w) => {
                    const isSelected = base.acceptsClientConfig?.includes(w)
                    return {
                        label: w,
                        checkbox: true,
                        selected: isSelected
                    }
                })
            })
            .on('treelistselect', function (event, node) {
                const events = [{
                    t: 'edit',
                    node: base,
                    changes: {
                        acceptsClientConfig: [...base.acceptsClientConfig]
                    },
                    dirty: true,
                    changed: true
                }]
                let changed = false
                if (!node.selected && base.acceptsClientConfig?.includes(node.label)) {
                    // remove it from the list
                    base.acceptsClientConfig = base.acceptsClientConfig.filter((w) => w !== node.label)
                    changed = true
                } else if (node.selected && !base.acceptsClientConfig?.includes(node.label)) {
                    // add it to the list
                    base.acceptsClientConfig.push(node.label)
                    changed = true
                }
                if (changed) {
                    recordEvents(events)
                }
            })
    }

    function refreshSidebarEditors () {
        try {
            refreshBusy = true
            if (RED._db2debug) { console.log('dashboard 2: ui_base.html: refreshSidebarEditors ()') }
            const layoutOrderDiv = $('.nrdb2-layout-order-editor')
            const themesDiv = $('.nrdb2-themes-editor')
            // empty the list if any items exist
            if (layoutOrderDiv.length) {
                layoutOrderDiv.empty()
            }
            if (themesDiv.length) {
                themesDiv.empty()
            }
            // now rebuild any sidebars that are dependent upon other nodes
            buildLayoutOrderEditor()
            buildThemesEditor()

            // finally, restore previous state of expanded/collapsed items
            // TODO: expand/collapse any items that were expanded before
            // for now, we will just re-sync the display level
            updateLayoutVisibility(layoutDisplayLevel)
        } finally {
            refreshBusy = false
        }
    }

    function addSidebarTab (label, id, tabs, sidebar) {
        $(`<li id="dashboard-2-tab-${id}" class="red-ui-tab" style="min-width: 90px; width: fit-content"><a class="red-ui-tab-label" style="padding-inline: 12px" title="Layout"><span>${label}</span></a></li>`).appendTo(tabs)

        // Add in Tab Content
        const content = $(`<div id="dashboard-2-${id}" class="red-ui-tab-content" style="height: calc(100% - 72px);"></div>`).appendTo(sidebar)
        return content
    }

    function addSidebar () {
        if (RED._db2debug) { console.log('dashboard 2: ui_base.html: addSidebar ()') }
        RED.sidebar.addTab({
            id: 'dashboard-2.0',
            label: c_('label.dashboard2'),
            name: c_('label.dashboard2'),
            content: sidebar,
            closeable: true,
            pinned: true,
            disableOnEdit: true,
            iconClass: 'fa fa-bar-chart',
            action: '@flowfuse/node-red-dashboard:show-dashboard-2.0-tab',
            onchange: () => {
                sidebar.empty()

                // UI Base Header
                RED.nodes.eachConfig(function (n) {
                    if (n.type === 'ui-base') {
                        const base = n
                        sidebar.append(dashboardLink(base.id, base.name, base.path))
                        const divTab = $('<div class="red-ui-tabs">').appendTo(sidebar)

                        const ulDashboardTabs = $('<ul id="dashboard-tabs-list"></ul>').appendTo(divTab)

                        // Add in Tabs
                        // Tab - Pages
                        const pagesContent = addSidebarTab(c_('label.layout'), 'pages', ulDashboardTabs, sidebar)
                        const themesContent = addSidebarTab(c_('label.theming'), 'themes', ulDashboardTabs, sidebar)
                        const cConstraintsContent = addSidebarTab(c_('label.constraints'), 'client-constraints', ulDashboardTabs, sidebar)

                        // check for any third-party tab definitions
                        RED.plugins.getPluginsByType('node-red-dashboard-2').forEach(plugin => {
                            if (plugin.tabs) {
                                plugin.tabs.forEach(tab => {
                                    // add tab to sidebar
                                    const container = addSidebarTab(tab.label, tab.id, ulDashboardTabs, sidebar)
                                    container.hide()
                                    tab.init(base, container)
                                })
                            }
                        })

                        // on tab click, show the tab content, and hide the others
                        ulDashboardTabs.children().on('click', function (evt) {
                            const tab = $(this)
                            const tabContent = $('#' + tab.attr('id').replace('-tab', ''))
                            ulDashboardTabs.children().removeClass('active')
                            tab.addClass('active')
                            $('.red-ui-tab-content').hide()
                            tabContent.show()
                            evt.preventDefault()
                        })

                        // default to first tab
                        ulDashboardTabs.children().first().trigger('click')

                        // add page/layout editor
                        buildLayoutOrderEditor(pagesContent)
                        // add Themes View
                        buildThemesEditor(themesContent)
                        // add Themes View
                        buildClientConstraintsEditor(base, cConstraintsContent)
                    }
                })
            }
        })
        RED.actions.add('@flowfuse/node-red-dashboard:show-dashboard-2.0-tab', function () {
            RED.sidebar.show('flowfuse-nr-tools')
        })
    }

    /**
     * jQuery widget to provide a selector for the sizing (width & height) of a widget & group
     */
    $.widget('nodereddashboard.elementSizer', {
        _create: function () {
            // convert to i18 text
            function c_ (x) {
                return RED._(`@flowfuse/node-red-dashboard/ui-base:ui-base.${x}`)
            }

            const thisWidget = this
            let gridWidth = 6
            const width = parseInt($(this.options.width).val() || 0)
            const height = parseInt(hasProperty(this.options, 'height') ? $(this.options.height).val() : '1') || 0
            const hasAuto = (!hasProperty(this.options, 'auto') || this.options.auto)

            this.element.css({
                minWidth: this.element.height() + 4
            })
            const autoText = c_('auto')
            const sizeLabel = (width === 0 && height === 0) ? autoText : width + (hasProperty(this.options, 'height') ? ' x ' + height : '')
            this.element.text(sizeLabel).on('mousedown', function (evt) {
                evt.stopPropagation()
                evt.preventDefault()

                const width = parseInt($(thisWidget.options.width).val() || 0)
                const height = parseInt(hasProperty(thisWidget.options, 'height') ? $(thisWidget.options.height).val() : '1') || 0
                let maxWidth = 0
                let maxHeight
                let fixedWidth = false
                const fixedHeight = false
                const group = $(thisWidget.options.group).val()
                if (group) {
                    const groupNode = RED.nodes.node(group)
                    if (groupNode) {
                        gridWidth = Math.max(6, groupNode.width, +width)
                        maxWidth = groupNode.width || gridWidth
                        fixedWidth = true
                    }
                    maxHeight = Math.max(6, +height + 1)
                } else {
                    gridWidth = Math.max(12, +width)
                    maxWidth = gridWidth
                    maxHeight = height + 1
                    // fixedHeight = false;
                }

                const pos = $(this).offset()
                const container = $('<div>').css({
                    position: 'absolute',
                    background: 'var(--red-ui-secondary-background, white)',
                    padding: '5px 10px 10px 10px',
                    border: '1px solid var(--red-ui-primary-border-color, grey)',
                    zIndex: '20',
                    borderRadius: '4px',
                    display: 'none'
                }).appendTo(document.body)

                let closeTimer
                container.on('mouseleave', function (evt) {
                    closeTimer = setTimeout(function () {
                        container.fadeOut(200, function () { $(this).remove() })
                    }, 100)
                })
                container.on('mouseenter', function () {
                    clearTimeout(closeTimer)
                })

                const label = $('<div>').css({
                    fontSize: '13px',
                    color: 'var(--red-ui-tertiary-text-color, #aaa)',
                    float: 'left',
                    paddingTop: '1px'
                }).appendTo(container).text((width === 0 && height === 0) ? autoText : (width + (hasProperty(thisWidget.options, 'height') ? ' x ' + height : '')))
                label.hover(function () {
                    $(this).css('text-decoration', 'underline')
                }, function () {
                    $(this).css('text-decoration', 'none')
                })

                label.click(function (e) {
                    const group = $(thisWidget.options.group).val()
                    let groupNode = null
                    if (group) {
                        groupNode = RED.nodes.node(group)
                        if (groupNode === null) {
                            return
                        }
                    }
                    $(thisWidget).elementSizerByNum({
                        width: thisWidget.options.width,
                        height: thisWidget.options.height,
                        groupNode,
                        pos,
                        label: thisWidget.element,
                        has_height: hasProperty(thisWidget.options, 'height')
                    })
                    closeTimer = setTimeout(function () {
                        container.fadeOut(200, function () {
                            $(this).remove()
                        })
                    }, 100)
                })

                const buttonRow = $('<div>', { style: 'text-align:right; height:25px;' }).appendTo(container)

                if (hasAuto) {
                    $('<a>', { href: '#', class: 'editor-button editor-button-small', style: 'margin-bottom:5px' })
                        .text(autoText)
                        .appendTo(buttonRow)
                        .on('mouseup', function (evt) {
                            thisWidget.element.text(autoText)
                            $(thisWidget.options.width).val(0).change()
                            $(thisWidget.options.height).val(0).change()
                            evt.preventDefault()
                            container.fadeOut(200, function () { $(this).remove() })
                        })
                }

                const cellBorder = '1px dashed var(--red-ui-secondary-border-color, lightGray)'
                const cellBorderExisting = '1px solid gray'
                const cellBorderHighlight = '1px dashed var(--red-ui-primary-border-color, black)'
                const rows = []
                const cells = []

                function addRow (i) {
                    const row = $('<div>').css({ padding: 0, margin: 0, height: '25px', 'box-sizing': 'border-box' }).appendTo(container)
                    rows.push(row)
                    cells.push([])
                    for (let j = 0; j < gridWidth; j++) {
                        addCell(i, j)
                    }
                }

                function addCell (i, j) {
                    const row = rows[i]
                    const cell = $('<div>').css({
                        display: 'inline-block',
                        width: '25px',
                        height: '25px',
                        borderRight: (j === (width - 1) && i < height) ? cellBorderExisting : cellBorder,
                        borderBottom: (i === (height - 1) && j < width) ? cellBorderExisting : cellBorder,
                        boxSizing: 'border-box',
                        cursor: 'pointer',
                        background: (j < maxWidth) ? 'var(--red-ui-secondary-background, #fff)' : 'var(--red-ui-node-background-placeholder, #eee)'
                    }).appendTo(row)
                    cells[i].push(cell)
                    if (j === 0) {
                        cell.css({ borderLeft: ((i <= height - 1) ? cellBorderExisting : cellBorder) })
                    }
                    if (i === 0) {
                        cell.css({ borderTop: ((j <= width - 1) ? cellBorderExisting : cellBorder) })
                    }
                    if (j < maxWidth) {
                        cell.data('w', j)
                        cell.data('h', i)
                        cell.on('mouseup', function () {
                            thisWidget.element.text(($(this).data('w') + 1) + (hasProperty(thisWidget.options, 'height') ? ' x ' + ($(this).data('h') + 1) : ''))
                            $(thisWidget.options.width).val($(this).data('w') + 1).change()
                            $(thisWidget.options.height).val($(this).data('h') + 1).change()
                            container.fadeOut(200, function () { $(this).remove() })
                        })
                        cell.on('mouseover', function () {
                            const w = $(this).data('w')
                            const h = $(this).data('h')
                            label.text((w + 1) + (hasProperty(thisWidget.options, 'height') ? ' x ' + (h + 1) : ''))
                            for (let y = 0; y < maxHeight; y++) {
                                for (let x = 0; x < maxWidth; x++) {
                                    cells[y][x].css({
                                        background: (y <= h && x <= w) ? 'var(--red-ui-secondary-background-selected, #ddd)' : 'var(--red-ui-secondary-background, #fff)',
                                        borderLeft: (x === 0 && y <= h) ? cellBorderHighlight : (x === 0) ? ((y <= height - 1) ? cellBorderExisting : cellBorder) : '',
                                        borderTop: (y === 0 && x <= w) ? cellBorderHighlight : (y === 0) ? ((x <= width - 1) ? cellBorderExisting : cellBorder) : '',
                                        borderRight: (x === w && y <= h) ? cellBorderHighlight : ((x === width - 1 && y <= height - 1) ? cellBorderExisting : cellBorder),
                                        borderBottom: (y === h && x <= w) ? cellBorderHighlight : ((y === height - 1 && x <= width - 1) ? cellBorderExisting : cellBorder)
                                    })
                                }
                            }
                            if (!fixedHeight && h === maxHeight - 1) {
                                addRow(maxHeight++)
                            }
                            if (!fixedWidth && w === maxWidth - 1) {
                                maxWidth++
                                gridWidth++
                                for (let r = 0; r < maxHeight; r++) {
                                    addCell(r, maxWidth - 1)
                                }
                            }
                        })
                    }
                }
                for (let i = 0; i < maxHeight; i++) {
                    addRow(i)
                }
                container.css({
                    top: (pos.top) + 'px',
                    left: (pos.left) + 'px'
                })
                container.fadeIn(200)
            })
        }
    })
})()
</script>

<script type="text/html" data-template-name="ui-base">
    <div class="form-row">
        <label for="node-config-input-name"><i class="fa fa-tag"></i> <span data-i18n="node-red:common.label.name"></label>
        <input type="text" id="node-config-input-name" data-i18n="[placeholder]node-red:common.label.name">
    </div>
    <div class="form-row">
        <label for="node-config-input-path"><i class="fa fa-bookmark"></i> <span data-i18n="ui-base.label.path"></label>
        <input type="text" id="node-config-input-path" disabled>
        <span style="display: block; margin-left: 105px; margin-top: 0px; font-style: italic; color: #bbb; font-size: 8pt;">This option is currently disabled and still in-development.</span>
    </div>
    <div class="form-row" style="margin-bottom: 0;">
        <label style="font-weight: 600; width: auto;" data-i18n="ui-base.label.navigation"></label>
    </div>
    <div class="form-row" style="align-items: center;">
        <label style="margin-right: 5px; margin-bottom: 0px;" for="node-config-input-titleBarStyle"><span data-i18n="ui-base.label.titleBarStyle"></span></label>
        <select id="node-config-input-titleBarStyle">
            <option value="default" data-i18n="ui-base.label.titleBarStyleDefault"></option>
            <option value="hidden" data-i18n="ui-base.label.titleBarStyleHidden"></option>
            <option value="fixed" data-i18n="ui-base.label.titleBarStyleFixed"></option>
        </select>
    </div>
    <div class="form-row" style="margin-bottom: 0;">
        <label style="font-weight: 600; width: auto;" data-i18n="ui-base.label.sidebar"></label>
    </div>
    <div class="form-row" style="align-items: center;">
        <label style="margin-right: 5px; margin-bottom: 0px;" for="node-config-input-navigationStyle"><span data-i18n="ui-base.label.navigationStyle"></span></label>
        <select id="node-config-input-navigationStyle">
            <option value="default" data-i18n="ui-base.label.navigationStyleDefault"></option>
            <option value="fixed" data-i18n="ui-base.label.navigationStyleFixed"></option>
            <option value="icon" data-i18n="ui-base.label.navigationStyleIcon"></option>
            <option value="temporary" data-i18n="ui-base.label.navigationStyleTemporary"></option>
            <option value="none" data-i18n="ui-base.label.navigationStyleNone"></option>
        </select>
    </div>
    <div class="form-row form-row-flex" style="align-items: center;">
        <input style="margin: 8px 0 10px 16px; width:20px;" type="checkbox" id="node-config-input-showPathInSidebar">
        <label style="width:auto" for="node-config-input-showPathInSidebar"><span data-i18n="ui-base.label.showPath"></span></label>
    </div>
</script>
